Embeddings -- Integridad y asincroniedad
Con las tablas y vistas ya definidas, el siguiente paso es decidir el flujo: qué ocurre exactamente entre que un usuario crea o modifica un recurso de DWall y el embedding queda persistido en module_embeddings_<tipo>.
Este subcapítulo recorre el proceso de decisión. La arquitectura interna del módulo (qué clases existen, cómo se inyectan, qué responsabilidades tiene cada una) no se introduce de golpe: aparece a medida que cada pieza encaja en el flujo.
¿Cuándo y dónde se generan los embeddings?
Este es un problema más complejo de lo que puede parecer, y uno que ya tiene patrones y soluciones propuestas por expertos en la materia. En el mundo de la informática es importante no reinventar la rueda constantemente, así que para abordarlo vamos a analizar las propuestas formuladas por el canal de divulgación informática Codely TV.
Un aspecto que preocupó a uno de mis tutores respecto a la generación de embeddings es que, en DWall, puede darse el caso de que algún cliente almacene sus recursos directamente sobre su respectiva base de datos — sin pasar por la API. Eso supone un problema, porque el recurso entra al sistema de forma totalmente externa al flujo de la aplicación, lo que en el caso de nuestros embeddings se traduciría en que estos nunca llegaran a crearse.
Para solucionarlo y garantizar la integridad de los embeddings, lo más sencillo suele ser crear un trigger que, cada vez que se altere o inserte un dato nuevo en nuestra base de datos, se asegure de que el embedding asociado se actualice en consecuencia.
Sin embargo, esta solución solo garantiza la integridad de los datos y, a cambio, introduce una debilidad muy peligrosa que puede ocasionar un empeoramiento superlativo en el rendimiento de cualquier sistema web. Los embeddings, tal como se explicó previamente, suelen y deben ser generados por un LLM especializado en ello (en nuestro caso, la API de Gemini), lo que requiere efectuar una llamada HTTP a un servicio externo.
Desde la capa de aplicación esto no supondría ningún problema, pero hacerlo desde la capa de base de datos significa lanzar una llamada bloqueante desde el propio trigger. Implementarlo de esa forma sería catastrófico para el rendimiento: por cada recurso creado tendríamos una conexión de la base de datos esperando la respuesta de un servicio web externo que puede sufrir latencia elevada o, en el peor caso, estar completamente caído.
Es aquí donde cobra importancia el curso de este canal, ya que analiza los conceptos arquitectónicos y los flujos posibles que puede seguir la aplicación. La clasificación útil para razonar sobre ellos se basa en dos ejes: dónde vive la lógica (en la base de datos o en la aplicación) y cuándo ocurre la generación (síncrona dentro de la transacción del usuario, o asíncrona). Combinando ambos ejes salen las cuatro opciones del curso:
| # | Dónde | Cuándo | Resumen | Por qué se descarta |
|---|---|---|---|---|
| 1 | App | Síncrono | El endpoint llama a Gemini antes de responder al usuario | El usuario paga ~500 ms de latencia en cada save por algo que no le aporta valor |
| 2 | App | Asíncrono | Cada módulo publica un evento y un listener regenera | Cada bounded context tendría que recordar publicar el evento de embedding — exactamente el acoplamiento que se quería evitar |
| 3 | BD | Síncrono | Trigger + pgsql-http llaman a Gemini en la misma transacción | Bloquea la transacción del usuario; si Gemini cae, los datos no se guardan |
| 4 | BD | Asíncrono | Trigger + pg_net + pg_cron + PGMQ encolan y llaman fuera | Toda la lógica de generación vive en PL/pgSQL: intestable, sin retries serios, sin trazabilidad de errores |
Los descartes son rápidos. La 1 y la 3 sacrifican latencia del usuario por una operación que no le aporta nada — al usuario no le importa el embedding, le importa que su descripción se guarde rápido. La 2 funciona, pero ya la hemos descartado previamente por el riesgo de perder por completo la integridad de los embeddings si al cliente se le ocurriera insertar los datos directamente en la base de datos. La 4 es, sin lugar a duda, la mejor de las cuatro y la seleccionada por el curso en cuestión; sin embargo, presenta un problema logístico que la hace inviable en nuestro caso: ni pg_net ni pg_cron están soportados en Google Cloud SQL, que es el motor sobre el que corre la infraestructura productiva de DWall. ¡No se puede lanzar en producción!
Si ninguna de las cuatro opciones encaja en nuestro contexto, ¿queda alguna esperanza?
Es en este punto, ya fuera del catálogo del curso, donde surgió una quinta vía propia que no aparecía en el material original. La idea, formulada como reacción a las limitaciones de las cuatro anteriores, fue dividir responsabilidades en lugar de delegarlas en un único actor: que el trigger se encargara únicamente de detectar el cambio — su trabajo natural dentro de la base de datos — y que la lógica de generación viviera donde le corresponde, en la capa de aplicación.
El trigger se entera de que algo cambió; el código Java decide qué hacer al respecto. La pregunta que queda abierta es la concreta: ¿cómo se comunican el trigger y la aplicación?
La pieza que ya estaba: el event bus de DWall
Para ello rescataremos una pieza fundamental descrita en el capítulo de eventos de dominio: las tablas system_events_domain_events_v2 (registra el evento de dominio) y system_events_event_publications (lo marca como PENDING para los suscriptores). Es la misma infraestructura que el resto de módulos ya usa para publicar VariableCreated, RuleChanged, TagCreated y compañía.
La decisión clave del módulo es reutilizar ese bus como mecanismo de cola de la estrategia. El trigger no llama a una API externa, no encola en PGMQ, no programa nada. Hace algo bastante más simple: dentro de la misma transacción del cambio que detecta, inserta dos filas en system_events_* exactamente como lo haría cualquier servicio Java al publicar un evento de dominio:
El detalle aparentemente técnico tiene una consecuencia mayor: el evento que dispara la generación deja de ser un mecanismo SQL y se convierte en un evento de dominio normal. Vive en el bus, lo recoge un @EventListener Spring igual que cualquier otro listener del backend, se procesa fuera de la transacción del usuario, y a partir de ese punto todo lo que ocurre es código Java estándar. Tres propiedades que normalmente requieren tres piezas distintas se consiguen con una sola decisión:
- Integridad. El evento se inserta en la misma transacción que el cambio del recurso. Por la propia naturaleza de los eventos de dominio nunca podemos garantizar la integridad de los datos al 100%, pero de esta forma cedemos esa responsabilidad al bus de eventos de DWall.
- Asincronía. El procesamiento del evento ocurre tras el
COMMIT, fuera de la transacción del usuario. La llamada a Gemini —que puede tardar entre 200 ms y varios segundos, o incluso fallar por completo— no afecta al tiempo de respuesta del endpoint ni mantiene bloqueadas las conexiones que PostgreSQL tiene abiertas. - Capa de aplicación. El listener es Java de toda la vida. Puede enriquecer el contexto consultando vistas, deduplicar contra el texto ya almacenado para evitar llamadas redundantes a Gemini, reintentar ante errores transitorios, ser testeado con dobles…